VPN and LocalDevVPN
VPN is an injected global for active scripts in the main app. No import is required. Use VPN.getState(), VPN.start() and VPN.stop() to query or control this device's Scripting VPN. Start and stop require the vpn script permission. Reads do not request control access.
The following example starts Scripting’s VPN. When an external VPN already provides reflection, skip it and use the RSD section below.
start() resolves after the owned tunnel is connected. A transitional connecting, reasserting or disconnecting state rejects as busy; read again before retrying. stop() resolves after the owned tunnel is disconnected and its stop preferences have been processed. Stop also closes owned RSD sessions. Script exit does not stop an already accepted global VPN operation. A completion timeout can reject while the accepted device operation continues; query state before retrying. Connection status alone does not prove traffic succeeded.
VPN.localDevVPN.getState() reads saved device intent and actual reflection state. setEnabled(enabled, {expectedState?}) changes only intent and retains addresses. Setting it while disconnected keeps the VPN off. The setting survives configuration switches and script exit. The localDevVPN permission is separate from vpn. A recorded denial is respected; errors retain machine-readable code, reason, permission and permissionScope where applicable.
A stale token rejects without overwriting intent. Omit the token to use the state immediately before the atomic save. Concurrent state changes during sampling reject. A successful save followed by a failed read can reject after saving: read again to reconcile.
RSD tunnel
Reflection and ordinary networking
VPN.localDevVPN.setEnabled(true) enables local-device reflection intent. VPN.start() starts the selected full Scripting proxy configuration, including its routing rules and DNS policy. Reflection does not automatically replace that policy with a reflection-only tunnel or DIRECT networking. Make sure ordinary traffic is allowed by the selected configuration; an isolated test profile that rejects other destinations is unsuitable for geocoding and web requests.
When another app supplies reflection, keep its VPN connected and call openRSDTunnel directly. Its own rules and DNS determine ordinary networking; Scripting does not modify them. Scripting's effectiveStatus: "inactive" is expected in that case.
Location.reverseGeocode uses the system geocoding service. A kCLErrorDomain code 2 rejection can mean connectivity problems or app-level rate limiting. Cache and coalesce lookups, avoid querying every map-camera update, and avoid rapid retries. Code 8 means no result. Compare a fixed coordinate at a low rate with VPN off, an allowed DIRECT configuration, and the external VPN if applicable. A successful web request alone does not prove the geocoder's backend path is healthy; retain the geocoder result too.
Open and exchange
VPN.localDevVPN.openRSDTunnel({host, port, preSharedKey, identity?, mtu?}) requires a reachable reflected numeric IPv4 address, localDevVPN permission and a previously obtained 32-byte pairing PSK (Data, Uint8Array or ArrayBuffer). Identity is an optional UTF-8 string of at most 128 bytes. MTU defaults to 16000 and supports 1280–16384. This API opens the CDTunnel transport and bounded private IPv6 TCP connections. It does not discover or pair Apple devices or implement service-specific business commands. An opened tunnel can query its RSD service directory.
The host uses the current system route. An external proxy or wloc may provide the reflection: configure and connect it first, then call this method directly. Do not call VPN.start() or require effectiveStatus === "active" for this path. getState() continues to describe Scripting only; it does not report or control another app's VPN. No preflight connection is made: the actual PSK handshake and service payload prove reachability. A one-use listener must never be probed before opening. Special, loopback, multicast and FakeIP addresses are rejected.
One native tunnel can be shared by up to 16 live or releasing handles, with at most four connections in total (including pending opens). Each read/write is bounded to 16384 bytes, with one pending read and one pending write per connection. Read maximum must be 1–16384; an idle read times out after 120 seconds. Stream methods require the original active App script. Script exit and explicit close release only the owning handle and its connections. Sessions bound to a verified Scripting reflection also close on Scripting VPN stop and relevant local-device/configuration changes. External-route sessions remain independent of those settings; actual transport failures are reported by the session. Closing a handle waits for its own cleanup; the native tunnel is reclaimed after the last handle releases and child cleanup completes. Closing an old handle cannot close a replacement. A failed transport can still supply already verified buffered bytes before reporting failure. getState() reports negotiated addresses, MTU, RSD port, lifecycle state and bounded buffer counts. Keep session handles and close them when finished.
Each VPN state/start/stop method allows one pending request of its own kind. State reads and stop remain available while start is pending. A tunnel allows up to four pending connect requests; tunnel state/close and connection end/close each allow one pending request of their own kind. Await completion before issuing another request of the same kind.
Migration
The standalone LocalDevVPN global has been removed. Replace LocalDevVPN.getState() and LocalDevVPN.setEnabled(...) with VPN.localDevVPN.getState() and VPN.localDevVPN.setEnabled(...). Remove imports of these APIs from scripting; use the global declarations in the latest documentation and SDK. Permission identifiers remain localDevVPN for device intent/RSD, and vpn for VPN start/stop.
RSD lifecycle diagnostics
await tunnel.getState() includes closedReason for explicit lifecycle cleanup and terminalError for the first underlying terminal failure, or null if none. Explicit cleanup does not erase an earlier terminal failure. terminalError contains code, stage, nativeDomain, nativeCode, and optional networkErrorCategory (string) / networkErrorCode (number) details. RSD promise errors carry the same diagnostic fields; a closed-session rejection also carries closedReason. Permission errors retain their existing permission fields.
Common codes are RSD_SESSION_CLOSED, RSD_STREAM_CLOSED, RSD_TRANSPORT_EOF, RSD_TRANSPORT_TIMEOUT, RSD_TCP_TIMEOUT, RSD_PEER_RESET, RSD_NETWORK_FAILURE, RSD_PROTOCOL_ERROR, and RSD_CANCELLED. The numeric NSError code alone (including 0) does not identify an iOS termination or suspension. A single TCP reset can leave the tunnel usable; inspect both the operation error and tunnel state. Keep-alive state does not replace this check.
Handling access errors
Network operations check access before starting and may throw or reject with error.code === "ERR_PRO_REQUIRED". Attach socket/server error handlers before connecting or listening, and catch promises from Bonjour and VPN APIs. Keep cleanup in finally: closing sockets and RSD sessions, stopping Bonjour resources, VPN.stop() and disabling VPN.localDevVPN remain available after access expires.
Opening deadline and cancellation
openRSDTunnel({...options, timeout:5000}) adds a deadline in milliseconds (integer 100...120000), from authorization completion through old-cleanup waiting, route sampling, TLS and session publication. Permission-dialog time is excluded. Omit it to retain existing policy, including the transport's 10-second startup timer. A deadline rejects with RSD_OPEN_TIMEOUT / TimeoutError.
await VPN.localDevVPN.cancelRSDTunnelOpening() cancels only this context's pending open or attach and waits for native cleanup. It does not close an established tunnel; use tunnel.close(). The open rejects with RSD_CANCELLED / AbortError. If a script permission dialog is awaiting the user, cancellation prevents transport admission but completion waits for that dialog to finish. Cleanup remains available after access expires. Always create a fresh pairing listener before retrying.
A deadline initiates cancellation; rejection follows the required native cleanup, which can add time.
Shared handles and closing by port
If another script A provides an ensureTunnel service using global ScriptSession.serve, callers can request preparation and then acquire their own handle. A must return from its service callback while keeping its own handle alive; Script.exit(result) would release A's resources. The service command is implemented by A, not a built-in VPN command.
Service replies are JSON status, not transferable tunnel handles or pairing secrets. The recipient still obtains its own device-access authorization. If A should exit after handing over, attach successfully before acknowledging the handover to A.
A recipient script can call VPN.localDevVPN.attachRSDTunnel() to acquire its own handle to Scripting’s ready native tunnel, without receiving pairing credentials or opening parameters. Each recipient independently requests localDevVPN permission. It returns null when no native tunnel is ready (including an opening or closing tunnel); denied access or an invalid transport rejects. It never opens a new tunnel. Keep the creator’s handle alive until the recipient has attached. Once attached, either script can close or exit without closing the other’s handle; the final release reclaims the native tunnel.
Sharing is limited to the main App process, not extensions or another app’s in-memory tunnel. An external VPN may supply reflection, but a Scripting script must first open the native RSD tunnel. No handles or pairing secrets are transferred between scripts. attachRSDTunnel() has a 30-second deadline after authorization; cancelRSDTunnelOpening() also cancels this context’s pending attach. Open and attach share one pending-acquisition slot per context.
The existing matching-parameters openRSDTunnel path remains supported. Matching requires the same outer host/port, negotiated preSharedKey bytes, identity and requested MTU, not merely the same saved pairing record or inner service port. Mismatched options or route/security bindings reject instead of replacing an existing user’s tunnel.
This closes only this handle's connections to that port, including matching opens already pending when the close is applied. It never closes another handle's connections, even at the same port. Use connection.close() for one specific stream. Closing a parent handle also closes its streams adopted by DeviceServices/AFC. Merely having zero connections does not release a still-owned handle. RSD cleanup does not stop an external VPN or a Scripting VPN used by other features.
Behavior change: tunnel.close() now releases this handle instead of forcibly disconnecting every user of a shared native tunnel. Existing single-handle scripts keep the same cleanup effect. Scripts that depended on global disconnection must instead close each handle they own; the RSD handle API has no cross-owner force-close operation. App-wide VPN stops and security-binding changes can still invalidate all affected shared handles. See this documentation and the latest SDK for the updated lifecycle. getState() connection and buffer counts are scoped to the receiving handle.
RSD service directory
VPN and VPNRSDTunnel are globals, with no import required. On an opened tunnel, listServices() queries and caches the advertised service directory. Each result has name, port, usesRemoteXPC and entitlement (a string or null, describing the device-advertised entitlement). This metadata does not grant device privileges.
Names match exactly; aliases and guessed ports are not used. connect accepts either port or serviceName, never both, and returns the same raw byte connection. timeout is the total lookup/connection deadline, 1–300000 milliseconds, default 30000. resolveService resolves a name without connecting to its service; missing names reject with RSD_SERVICE_NOT_FOUND.
The shared native tunnel owns the directory snapshot. listServices({ refresh: true }) replaces it; a new tunnel starts with an empty cache. Existing service connections keep their own streams. Returned metadata is detached from the native cache. Lookup failures do not cache a permanent failure or replay service requests. Directory lookups temporarily consume one of the existing four connection slots; full capacity rejects with RSD_QUOTA. listServices and resolveService share a limit of 16 pending calls per handle. All lookup callers, including service opens, share a 64-waiter limit on the native tunnel. Timing out a request or closing its handle does not cancel other waiters. Service-specific protocols, including DTX and RemoteXPC business messages, remain script responsibilities.
Native Data input and stream methods
Data is the injected Scripting binary object; no import is required. preSharedKey accepts Data | Uint8Array | ArrayBuffer and must contain exactly 32 bytes. Both write and writeData snapshot the bytes at admission, so later mutation of the input cannot change the submitted operation. writeData accepts only native Data; write accepts all three representations. Writes remain limited to 16384 bytes per call, including the new method. Empty writes do not signal EOF; use end() for FIN.
readData(maximum?) returns a native Data chunk or null for peer EOF; it does not read a complete protocol message. read keeps returning Uint8Array | null. Both default to 16384 bytes, accept integer maximums from 1 to 16384, and share one pending-read slot. write and writeData share one pending-write slot. Mixing the method names cannot bypass concurrency limits. Errors reject, rather than returning null. Adoption and close invalidate both method families.
